package org.codefilarete.stalactite.engine.configurer.dslresolver;

import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Stream;
import javax.annotation.Nullable;

import org.codefilarete.reflection.AccessorChainMutator;
import org.codefilarete.reflection.AccessorDefinition;
import org.codefilarete.reflection.ReadWriteAccessorChain;
import org.codefilarete.reflection.ReadWritePropertyAccessPoint;
import org.codefilarete.reflection.ValueAccessPoint;
import org.codefilarete.reflection.ValueAccessPointComparator;
import org.codefilarete.reflection.ValueAccessPointMap;
import org.codefilarete.reflection.ValueAccessPointSet;
import org.codefilarete.stalactite.dsl.MappingConfigurationException;
import org.codefilarete.stalactite.dsl.naming.ColumnNamingStrategy;
import org.codefilarete.stalactite.engine.configurer.builder.embeddable.EmbeddableLinkage;
import org.codefilarete.stalactite.engine.configurer.builder.embeddable.EmbeddableMappingConfiguration;
import org.codefilarete.stalactite.engine.configurer.model.Entity.AbstractPropertyMapping;
import org.codefilarete.stalactite.engine.configurer.model.Entity.PropertyMapping;
import org.codefilarete.stalactite.engine.configurer.model.Entity.ReadOnlyPropertyMapping;
import org.codefilarete.stalactite.sql.ddl.Size;
import org.codefilarete.stalactite.sql.ddl.structure.Column;
import org.codefilarete.stalactite.sql.ddl.structure.Table;
import org.codefilarete.stalactite.sql.statement.SQLStatement.BindingException;
import org.codefilarete.stalactite.sql.statement.binder.ColumnBinderRegistry;
import org.codefilarete.stalactite.sql.statement.binder.ParameterBinder;
import org.codefilarete.stalactite.sql.statement.binder.ParameterBinderRegistry.EnumBindType;
import org.codefilarete.tool.Reflections;
import org.codefilarete.tool.bean.Objects;
import org.codefilarete.tool.collection.Arrays;
import org.codefilarete.tool.collection.KeepOrderSet;

import static org.codefilarete.stalactite.engine.configurer.builder.embeddable.EmbeddableMappingConfiguration.fromEmbeddableMappingConfiguration;
import static org.codefilarete.tool.Nullable.nullable;
import static org.codefilarete.tool.collection.Iterables.stream;

/**
 * Engine that converts mapping definition of a {@link EmbeddableMappingConfiguration} to a {@link Map} of {@link AbstractPropertyMapping}s.
 * It collects:
 * - direct properties (owned by the {@link EmbeddableMappingConfiguration})
 * - the embedded properties of the embedded configuration
 * - the properties found on inheritance by the mapped superclass definition (until no more mapped superclass is found)
 * - inherited properties of embedded configuration
 * 
 * Whereas it consumes an {@link EmbeddableMappingConfiguration} it doesn't mean that its goal is to manage embedded beans of an entity: as its
 * name says, it's aimed at collecting mapping of any beans, without the entity part (understanding identification and inheritance which is
 * {@link AggregateMetadataResolver}'s work).
 *
 * @author Guillaume Mary
 * @see #resolve(org.codefilarete.stalactite.dsl.embeddable.EmbeddableMappingConfiguration, Table, ColumnNamingStrategy)
 */
public class PropertyMappingResolver<C, T extends Table<T>> {
	
	private final ColumnBinderRegistry columnBinderRegistry;
	
	public PropertyMappingResolver(ColumnBinderRegistry columnBinderRegistry) {
		this.columnBinderRegistry = columnBinderRegistry;
	}
	
	/**
	 * Converts mapping definition of a {@link EmbeddableMappingConfiguration} into a simple {@link Map}
	 *
	 * @return a bean that stores some {@link Map}s representing the definition of the mapping declared by the {@link EmbeddableMappingConfiguration}
	 */
	public Set<AbstractPropertyMapping<C, ?, T>> resolve(org.codefilarete.stalactite.dsl.embeddable.EmbeddableMappingConfiguration<C> mainMappingConfiguration,
	                                                     T targetTable,
	                                                     ColumnNamingStrategy columnNamingStrategy) {
		return resolve(fromEmbeddableMappingConfiguration(mainMappingConfiguration),
				targetTable,
				columnNamingStrategy);
	}
	
	public Set<AbstractPropertyMapping<C, ?, T>> resolve(EmbeddableMappingConfiguration<C> mainMappingConfiguration,
	                                                     T targetTable,
	                                                     ColumnNamingStrategy columnNamingStrategy) {
		Set<AbstractPropertyMapping<C, ?, T>> result = new KeepOrderSet<>();
		
		InternalProcessor<C> internalProcessor = new InternalProcessor<>(targetTable, columnNamingStrategy);
		// converting direct mapping
		ValueAccessPointMap<C, String, ValueAccessPoint<C>> overriddenColumnNames = new ValueAccessPointMap<>();
		ValueAccessPointMap<C, Size, ValueAccessPoint<C>> overriddenColumnSizes = new ValueAccessPointMap<>();
		ValueAccessPointMap<C, Column<T, ?>, ValueAccessPoint<C>> overriddenColumns = new ValueAccessPointMap<>();
		ValueAccessPointSet<C, ?> excludedProperties = new ValueAccessPointSet<>();
		internalProcessor.includeMapping(mainMappingConfiguration,
				overriddenColumnNames,
				overriddenColumnSizes,
				overriddenColumns,
				excludedProperties);
		// adding insets
		result.addAll(includeInsets(targetTable, columnNamingStrategy, mainMappingConfiguration));
		
		// importing embeddable mapped superclass mapping
		EmbeddableMappingConfiguration<C> mappedSuperClassConfiguration = (EmbeddableMappingConfiguration<C>) mainMappingConfiguration.getMappedSuperClassConfiguration();
		while (mappedSuperClassConfiguration != null) {
			internalProcessor.includeMapping(mappedSuperClassConfiguration,
					overriddenColumnNames, overriddenColumnSizes, overriddenColumns, excludedProperties);
			// adding insets
			result.addAll(includeInsets(targetTable, columnNamingStrategy, mappedSuperClassConfiguration));
			mappedSuperClassConfiguration = (EmbeddableMappingConfiguration<C>) mappedSuperClassConfiguration.getMappedSuperClassConfiguration();
		}
		
		result.addAll(internalProcessor.result);
		
		return result;
	}
	
	private <E> Set<AbstractPropertyMapping<C, ?, T>> includeInsets(T targetTable, ColumnNamingStrategy columnNamingStrategy, EmbeddableMappingConfiguration<C> mainMappingConfiguration) {
		Set<AbstractPropertyMapping<C, ?, T>> result = new KeepOrderSet<>();
		mainMappingConfiguration.<E>getInsets().forEach(inset -> {
			InternalProcessor<E> internalProcessorForEmbeddedBeans = new InternalProcessor<>(targetTable, columnNamingStrategy);
			internalProcessorForEmbeddedBeans.includeMapping(inset.getConfiguration(),
					inset.getOverriddenColumnNames(),
					inset.getOverriddenColumnSizes(),
					(ValueAccessPointMap<E, Column<T, ?>, ValueAccessPoint<E>>) (ValueAccessPointMap) inset.getOverriddenColumns(),
					inset.getExcludedProperties());
			// shifting the embedded configuration by the inset accessor
			internalProcessorForEmbeddedBeans.result.forEach(mappingPawn -> {
				ReadWritePropertyAccessPoint<C, E> prefix = inset.getAccessor();
				AbstractPropertyMapping<C, ?, T> propertyMapping;
				if (mappingPawn instanceof ReadOnlyPropertyMapping) {
					propertyMapping = shiftMapping(prefix, (ReadOnlyPropertyMapping) mappingPawn);
				} else {
					propertyMapping = shiftMapping(prefix, (PropertyMapping) mappingPawn);
				}
				result.add(propertyMapping);
			});
		});
		return result;
	}
	
	private <X, Y> ReadOnlyPropertyMapping<C, Y, T> shiftMapping(ReadWritePropertyAccessPoint<C, X> prefix, ReadOnlyPropertyMapping<X, Y, T> mapping) {
		AccessorChainMutator<C, X, Y> shiftedAccessor = new AccessorChainMutator<>(Arrays.asList(prefix), mapping.getAccessPoint());
		return new ReadOnlyPropertyMapping<>(new ReadWriteAccessorChain<>(shiftedAccessor), mapping.getColumn(), mapping.isSetByConstructor(), mapping.getReadConverter(), mapping.isUnique());
	}
	
	private <X, Y> PropertyMapping<C, Y, T> shiftMapping(ReadWritePropertyAccessPoint<C, X> prefix, PropertyMapping<X, Y, T> mapping) {
		ReadWriteAccessorChain<C, X, Y> shiftedAccessor = new ReadWriteAccessorChain<>(Arrays.asList(prefix), mapping.getAccessPoint());
		return new PropertyMapping<>(shiftedAccessor, mapping.getColumn(), mapping.isSetByConstructor(), mapping.getReadConverter(), mapping.getWriteConverter(), mapping.isUnique());
	}
	
	/**
	 * Internal engine driven by {@link #resolve(org.codefilarete.stalactite.dsl.embeddable.EmbeddableMappingConfiguration, Table, ColumnNamingStrategy)} method.
	 * Made to store the result in another class than the main one and to decouple configuration from the processing.
	 *
	 * @author Guillaume Mary
	 */
	protected class InternalProcessor<E> {
		
		private final T targetTable;
		private final ColumnNamingStrategy columnNamingStrategy;
		
		private final Set<AbstractPropertyMapping<E, ?, T>> result = new KeepOrderSet<>();
		
		protected InternalProcessor(T targetTable, ColumnNamingStrategy columnNamingStrategy) {
			this.targetTable = targetTable;
			this.columnNamingStrategy = columnNamingStrategy;
		}
		
		protected <O> void includeMapping(EmbeddableMappingConfiguration<E> mappingConfiguration,
		                                  ValueAccessPointMap<E, String, ValueAccessPoint<E>> overriddenColumnNames,
		                                  ValueAccessPointMap<E, Size, ValueAccessPoint<E>> overriddenColumnSizes,
		                                  ValueAccessPointMap<E, Column<T, ?>, ValueAccessPoint<E>> overriddenColumns,
		                                  ValueAccessPointSet<E, ?> excludedProperties) {
			
			Stream<EmbeddableLinkage<E, O>> linkageStream = mappingConfiguration.<O>getPropertiesMapping().stream()
					.filter(linkage -> !excludedProperties.contains(linkage.getAccessor()));
			
			linkageStream.forEach(linkage -> {
				Column<T, O> overriddenColumn = (Column<T, O>) overriddenColumns.get(linkage.getAccessor());
				String columnName = nullable(overriddenColumn)
						.map(Column::getName)
						.getOr(() -> determineColumnName(linkage, overriddenColumnNames.get(linkage.getAccessor())));
				Size columnSize = nullable(overriddenColumn)
						.map(Column::getSize)
						.getOr(() -> determineColumnSize(linkage, overriddenColumnSizes.get(linkage.getAccessor())));
				assertMappingIsNotAlreadyDefinedByInheritance(linkage, columnName, mappingConfiguration);
				AbstractPropertyMapping<E, O, T> propertyMapping = createMapping(
						linkage,
						columnName,
						columnSize,
						overriddenColumn
				);
				result.add(propertyMapping);
			});
		}
		
		protected <O> String determineColumnName(EmbeddableLinkage<E, O> linkage, @Nullable String overriddenColumName) {
			return nullable(overriddenColumName)
					.elseSet(linkage::getColumnName)
					.elseSet(linkage::getFieldName)
					.elseSet(() -> columnNamingStrategy.giveName(AccessorDefinition.giveDefinition(linkage.getAccessor())))
					.get();
		}
		
		protected <O> Size determineColumnSize(EmbeddableLinkage<E, O> linkage, @Nullable Size overriddenColumSize) {
			return nullable(overriddenColumSize).elseSet(linkage::getColumnSize).get();
		}
		
		/**
		 * Includes a mapping of a single property.
		 * @param linkage the user-defined mapping of a property
		 * @param columnName the column name to use for this property
		 * @param columnSize the column size to use for this property, will fallbacks to a default one if not given
		 * @param overriddenColumn the column to use for this property, if not null, then the column name and size are ignored
		 * @return a coupled accessor and column
		 * @param <O> the property type
		 */
		private <O> AbstractPropertyMapping<E, O, T> createMapping(EmbeddableLinkage<E, O> linkage,
		                                                           String columnName,
		                                                           @Nullable Size columnSize,
		                                                           @Nullable Column<T, O> overriddenColumn) {
			Column<T, O> column = nullable(overriddenColumn).getOr(() -> addColumnToTable(
					linkage,
					linkage.getExtraTable() != null ? linkage.getExtraTable() : targetTable,
					columnName,
					columnSize)
			);
			
			ensureColumnBindingInRegistry(linkage, column);
			
			AbstractPropertyMapping<E, O, T> propertyMapping;
			if (!linkage.isReadonly()) {
				propertyMapping = new PropertyMapping<>(linkage.getAccessor(), column, linkage.isSetByConstructor(), linkage.getReadConverter(), linkage.getWriteConverter(), linkage.isUnique());
			} else {
				// we use getWriter() even if getLeft() is sufficient thanks to ReadWritePropertyAccessPoint implementing PropertyMutator
				// however getWrite() is more precise by removing a encapsulation layer, which potentially reflects much more user declaration
				propertyMapping = new ReadOnlyPropertyMapping<>(linkage.getAccessor().getWriter(), column, linkage.isSetByConstructor(), linkage.getReadConverter(), linkage.isUnique());
			}
			
			return propertyMapping;
		}
		
		protected <O> void assertMappingIsNotAlreadyDefinedByInheritance(EmbeddableLinkage<E, O> linkage, String columnNameToCheck, EmbeddableMappingConfiguration<E> mappingConfiguration) {
			DuplicateDefinitionChecker duplicateDefinitionChecker = new DuplicateDefinitionChecker(columnNameToCheck, linkage.getAccessor(), columnNamingStrategy);
			stream(mappingConfiguration.inheritanceIterable())
					.flatMap(configuration -> (Stream<EmbeddableLinkage>) configuration.getPropertiesMapping().stream())
					// not using equals() is voluntary since we want reference checking here to exclude same instance,
					// since given linkage is one of given mappingConfiguration
					// (doing as such also prevent equals() method override to break this algorithm)
					.filter(pawn -> linkage != pawn
							// only writable properties are concerned by this check : we allow duplicates for readonly properties
							&& !pawn.isReadonly() && !linkage.isReadonly())
					.forEach(duplicateDefinitionChecker);
		}
		
		protected <O> Column<T, O> addColumnToTable(EmbeddableLinkage<E, O> linkage, Table targetTable, String columnName, @Nullable Size columnSize) {
			Class<O> columnType;
			if (linkage.getColumnType().isEnum()) {
				if (linkage.getEnumBindType() == null) {
					columnType = (Class<O>) Enum.class;
				} else {
					columnType = linkage.getEnumBindType() == EnumBindType.NAME
							? (Class<O>) String.class
							: (Class<O>) Integer.class;
				}
			} else {
				if (linkage.getParameterBinder() != null) {
					// when a parameter binder is defined, then the column type must be binder one
					columnType = linkage.getParameterBinder().getColumnType();
				} else {
					columnType = linkage.getColumnType();
				}
			}
			// if the user asks for nullability, then we follow his demand, else we rely on the property type: primitive ones are mandatory
			Boolean isColumnNullable =
					nullable(linkage.isNullable()).getOr(() -> Reflections.isPrimitiveType(linkage.getColumnType()) ? false : null);
			return targetTable.addColumn(columnName, columnType, columnSize, isColumnNullable);
		}
		
		protected <O> void ensureColumnBindingInRegistry(EmbeddableLinkage<E, O> linkage, Column<?, O> column) {
			if (linkage.getColumnType().isEnum()) {
				EnumBindType enumBindType = Objects.preventNull(linkage.getEnumBindType(), EnumBindType.ORDINAL);
				columnBinderRegistry.register(column, enumBindType.newParameterBinder((Class<Enum>) linkage.getColumnType()));
			} else if (linkage.getParameterBinder() != null) {
				columnBinderRegistry.register(column, (ParameterBinder<O>) linkage.getParameterBinder());
			} else {
				try {
					// assertion to check that column binder is registered : it will throw en exception if the binder is not found
					columnBinderRegistry.getBinder(column);
				} catch (BindingException e) {
					throw new MappingConfigurationException("No binder found for property " + AccessorDefinition.toString(linkage.getAccessor())
							+ " : neither its column nor its type are registered (" + column.getAbsoluteName() + ", type " + Reflections.toString(column.getJavaType()) + ")", e);
				}
			}
		}
	}
	
	static class DuplicateDefinitionChecker implements Consumer<EmbeddableLinkage> {
		
		private final String columnNameToCheck;
		private final ReadWritePropertyAccessPoint propertyAccessor;
		private final ColumnNamingStrategy columnNameStrategy;
		private static final ValueAccessPointComparator VALUE_ACCESS_POINT_COMPARATOR = new ValueAccessPointComparator();
		
		DuplicateDefinitionChecker(String columnNameToCheck, ReadWritePropertyAccessPoint propertyAccessor, ColumnNamingStrategy columnNameStrategy) {
			this.columnNameToCheck = columnNameToCheck;
			this.propertyAccessor = propertyAccessor;
			this.columnNameStrategy = columnNameStrategy;
		}
		
		@Override
		public void accept(EmbeddableLinkage pawn) {
			ReadWritePropertyAccessPoint accessor = pawn.getAccessor();
			if (VALUE_ACCESS_POINT_COMPARATOR.compare(accessor, propertyAccessor) == 0) {
				throw new MappingConfigurationException("Mapping is already defined by method " + AccessorDefinition.toString(propertyAccessor));
			} else if (columnNameToCheck.equals(pawn.getColumnName())
					|| columnNameToCheck.equals(columnNameStrategy.giveName(AccessorDefinition.giveDefinition(accessor)))) {
				throw new MappingConfigurationException("Column '" + columnNameToCheck + "' of mapping '" + AccessorDefinition.toString(propertyAccessor)
						+ "' is already targeted by '" + AccessorDefinition.toString(pawn.getAccessor()) + "'");
			}
		}
	}
}
